"""
This module provides an alternative method to generate items for instances of the Stacking Problem.
The idea is to simulate supply and demand for products. Production time distributions and demand 
parameters (order size, order interval, due dates) are specified, and a simulation is run using 
these values to create more realistic scenarios. 
"""
from collections import deque
from bisect import bisect_right
import random
import time

from utils.interval import Interval
from utils.math import sample
from utils.misc import IDGenerator, INF

from stacking.item import Item

# -----------------------------------------------------------------------------
# Auxiliary functions
id_generator = IDGenerator()

def make_interval(value, lo=-0.1, hi=+0.1):
    """Creates an interval around a given 'value' and relative deviations 'lo' and 'hi'."""
    return Interval(value * (1.0 + lo), value * (1.0 + hi))
    
def make_distr(name, *args):
    def distr(rng):
        return getattr(rng, name)(*args)
    return distr
    
def make_gaussian(mu, sigma):
    def distr(rng):
        return rng.gauss(mu, sigma)
    return distr
    
def make_exponential(mean):
    rate = 1.0 / mean
    def distr(rng):
        return rng.expovariate(rate)
    return distr
    
def make_constant(value):
    def distr(rng):
        return value
    return distr
    
def make_uniform(a, b):
    def distr(rng):
        return rng.uniform(a, b)
    return distr
    
def deque_insert(deque, index, value):
    deque.rotate(-index)
    deque.appendleft(value)
    deque.rotate(index)
    
def deque_pop(deque, index):
    deque.rotate(-index)
    value = deque.popleft()
    deque.rotate(index)
    return value
    
# End of auxiliary functions
# -----------------------------------------------------------------------------

class Producer(object):
    """Models supply for a list of products."""
    def __init__(self, products, production_interval_distr, batch_size_distr, id=None):
        self.products = list(products)
        self.production_interval_distr = production_interval_distr
        self.batch_size_distr = batch_size_distr
        self.id = id_generator.generate(self) if id is None else id
        
    @classmethod
    def create_gg(cls, products, prodinterval_mu, prodinterval_sigma, 
                  batchsize_mu, batchsize_sigma, id=None):
        """Create a producer with a Gaussian distribution of production intervals and production 
        batch sizes."""
        return cls(products, 
                   make_gaussian(prodinterval_mu, prodinterval_sigma), 
                   make_gaussian(batchsize_mu, batchsize_sigma), 
                   id)
        
    @classmethod
    def create_xg(cls, products, prodinterval_mean, 
                  batchsize_mu, batchsize_sigma, id=None):
        """Create a producer with an exponential distribution of production intervals and Gaussian 
        distribution of batch sizes."""
        return cls(products, 
                   make_exponential(prodinterval_mean),
                   make_gaussian(batchsize_mu, batchsize_sigma), 
                   id)
        
    def generate(self, rng, time=0.0):
        """An infinite stream of item release intervals and product names."""
        while True:
            ptime = sample.positive(self.production_interval_distr, rng)
            release = make_interval(ptime).shift(time)
            product = rng.choice(self.products)
            quantity = int(sample.nonnegative(self.batch_size_distr, rng))
            time += ptime
            yield (release, product, quantity, time)
            
            
class Consumer(object):
    """Generates demand for a list of products."""
    def __init__(self, products, order_interval_distr, 
                 order_due_distr, order_size_distr, id=None):
        self.products = list(products)
        self.order_interval_distr = order_interval_distr
        self.order_due_distr = order_due_distr
        self.order_size_distr = order_size_distr
        self.id = id_generator.generate(self) if id is None else id
        
    @classmethod
    def create_xgg(cls, products, ordinterval_mean, 
                   orddue_mu, orddue_sigma, 
                   ordsize_mu, ordsize_sigma, 
                   id=None):
        """Create a consumer with exponential distribution of order intervals, and Gaussian 
        distributions for order due dates and size."""
        return cls(products, 
                   make_exponential(ordinterval_mean),
                   make_gaussian(orddue_mu, orddue_sigma),
                   make_gaussian(ordsize_mu, ordsize_sigma),
                   id)
        
    def generate(self, rng, time=0.0):
        """An infinite stream of order tuples."""
        while True:
            due = make_interval(sample.positive(self.order_due_distr, rng)).shift(time)
            product = rng.choice(self.products)
            quantity = int(sample.nonnegative(self.order_size_distr, rng))
            time += sample.positive(self.order_interval_distr, rng)
            yield (due, product, quantity, time)
            
            
class Simulation(object):
    """Represents a single simulation run. Simulates supply (Producers) and demand (Consumers) to 
    generate Item objects for the SP."""
    def __init__(self, producers=(), consumers=(), nitems=INF, seed=None):
        producers = list(producers)
        consumers = list(consumers)
        all_products = set()
        for producer in producers:
            all_products.update(producer.products)
        for consumer in consumers:
            if not all_products.issuperset(consumer.products):
                raise Exception("some products are unavailable for consumer %s" % consumer.id)
        if seed is None:
            seed = int(time.time() * 1000.0)
        # +++
        self.seed       = seed
        self.rng        = random.Random(seed)
        self.events     = deque((0.0, o.generate(self.rng), o) for o in producers + consumers)
        self.stock      = dict((p, deque()) for p in all_products)  # items waiting for order
        self.pending    = dict((p, deque()) for p in all_products)  # items waiting for release
        self.time       = 0.0     # simulation time
        self.nitems     = nitems  # number of items we wish to generate
        self.incomplete = 0       # number of items in stock or pending  
        self.complete   = 0       # number of items with complete information (rel&due)
        
    def __iter__(self):
        while self.complete < self.nitems:
            self.time, generator, obj = self.events.popleft()
            # retrieve current event by 'obj' and handle it
            interval, product, quantity, next_t = generator.next()
            handler = self._produced if isinstance(obj, Producer) else self._ordered
            for item in handler(obj, interval, product, quantity):
                yield item
            # create next event and insert it into the event list
            event = (next_t, generator, obj)
            index = bisect_right(self.events, event)
            deque_insert(self.events, index, event)
            assert self.nitems - self.complete - self.incomplete >= 0
            
    def _produced(self, producer, release, product, n):
        # first we match the production batch with pending orders for the specified product
        pending = self.pending[product]
        for x in xrange(min(n, len(pending))):
            item = pending.popleft()
            item.meta.producer = producer.id
            item.release = release.deepcopy()
            if item.release.start > item.due.end:
                item.due.shift(item.release.start - item.due.end)
            self.incomplete -= 1
            self.complete += 1
            n -= 1
            yield item
        # unconsumed items that were produced will go to stock
        stock = self.stock[product]
        for x in xrange(min(n, self.nitems - self.complete - self.incomplete)):
            item = Item(id=None, release=release.deepcopy(), due=None, 
                        producer=producer.id, product=product)
            stock.append(item)
            self.incomplete += 1
            
    def _ordered(self, consumer, due, product, n):
        # first we match the order with existing stock for the specified product
        stock = self.stock[product]
        for x in xrange(min(n, len(stock))):
            item = stock.popleft()
            item.meta.consumer = consumer.id
            item.due = due.deepcopy()
            if item.release.start > item.due.end:
                item.due.shift(item.release.start - item.due.end)
            self.incomplete -= 1
            self.complete += 1
            n -= 1
            yield item
        # remaining items will go to the pending orders list
        pending = self.pending[product]
        for x in xrange(min(n, self.nitems - self.complete - self.incomplete)):
            item = Item(id=None, release=None, due=due.deepcopy(), 
                        consumer=consumer.id, product=product)
            pending.append(item)
            self.incomplete += 1
            
            
class Simulator(object):
    """A simulator is nothing more than a factory for Simulation objects. Please refer to the 
    Simulation class' documentation."""
    def __init__(self, producers=(), consumers=()):
        self.producers = list(producers)
        self.consumers = list(consumers)
        
    def generate(self, nitems, seed=None):
        simulation  = Simulation(self.producers, self.consumers, nitems, seed)
        item_stream = iter(simulation)
        items       = list()
        for x in xrange(nitems):
            item = item_stream.next()
            item.id = x
            item.preprocess()
            items.append(item)
        return simulation.seed, items
        
        
def test_sim(nitems=100, seed=None):
    from stacking.units import MIN, HOUR, DAY
    
    # define some producers and consumers to create items via simulation
    products  = ["A"]
    producers = [Producer.create_gg(products, 20*MIN, 5*MIN, 1, 0)]
    consumers = [Consumer.create_xgg(products, *args) for args in
                 [(2*DAY, 7*DAY, 1*DAY, 30, 5), 
                  (1*DAY, 3*DAY, 6*HOUR, 10, 2), 
                  (12*HOUR, 1*DAY, 2*HOUR, 5, 1)]]
    simulator = Simulator(producers, consumers)
    seed, items = simulator.generate(nitems, seed)
    return seed, items, simulator
    
    
